// Copyright 2013 Stephane SOPPERA
//
// This file is part of 3ds-image-uploader.
//
// 3ds-image-uploader is free software: you can redistribute it and/or
// modify it under the terms of the GNU General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// 3ds-image-uploader is distributed in the hope that it will be
// useful, but WITHOUT ANY WARRANTY; without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with 3ds-image-uploader.  If not, see <http://www.gnu.org/licenses/>.

var http = require('http');
var url = require('url');
var fs = require('fs');
var path = require('path');
var template = require('./3ds-template.js');
var mime = require('./3ds-mime.js');
var assert = require('assert');

function filesize2string(filesize) {
    if (filesize <= 1)
	return filesize + " byte";
    if (filesize < 1024)
	return filesize + " bytes";
    if (filesize < 1024*1024)
	return Math.floor(filesize / 1024) + " Kb";
    if (filesize < 1024*1024*1024)
	return Math.floor(filesize / 1024 / 1024) + " Mb";
    return Math.floor(filesize / 1024 / 1024 / 1024) + " Gb";
}

assert.equal(filesize2string(0), "0 byte");
assert.equal(filesize2string(1), "1 byte");
assert.equal(filesize2string(2), "2 bytes");
assert.equal(filesize2string(1023), "1023 bytes");
assert.equal(filesize2string(1024), "1 Kb");
assert.equal(filesize2string(1024*1024 - 1), "1023 Kb");
assert.equal(filesize2string(1024*1024), "1 Mb");
assert.equal(filesize2string(1024*1024*1024 - 1), "1023 Mb");
assert.equal(filesize2string(1024*1024*1024), "1 Gb");

function sendHtml(response, code, str) {
    var buffer = new Buffer(str);
    response.writeHead(code, { 
	"Content-Type": "text/html; charset=UTF-8", 
	"Content-Length": buffer.length,
	'Cache-Control': 'max-age=10',
    });
    response.end(buffer);
}

function sendErr(response, code, msg) {
    sendTemplateFile(response, code, 'text/html; charset=UTF-8', '3ds-server-err.html', {
	desc: http.STATUS_CODES[code],
	code: code,
	msg: msg,
    }, true);
}

function sendFile(response, code, type, filename) {
    fs.readFile(path.resolve(scriptDir, filename), function (err, content) {
	if (err === null) {
	    response.writeHead('200', {
		'Content-Type': type,
		'Content-Length': content.length,
		'Cache-Control': 'max-age=10',
		// 'Pragma': 'no-cache'
	    });
	    response.end(content);
	} else
	    sendErr(response, 500, "Can't read the file: " + err);
    });
}

function sendTemplateFile(response, code, type, filename, values, fromSendErr) {
    fs.readFile(path.resolve(scriptDir, filename), function (err, content) {
	if (err === null) {
	    // extract charset from content type
	    var charset = mime.parseContentType(type).parameters.charset;
	    if (charset === undefined)
		charset = 'utf8';

	    // decode buffer using charset
	    var str = content.toString(charset);

	    // apply the template
	    str = template.apply(str, values);

	    // encode the result in charset
	    content = new Buffer(str, charset);
	    
	    response.writeHead(code, {
		'Content-Type': type,
		'Content-Length': content.length,
		'Cache-Control': 'max-age=10',
		// 'Pragma': 'no-cache'
	    });
	    response.end(content);
	} else {
	    if (fromSendErr) {
		console.log('Loop in send err');
		response.writeHead('500', {
		    'Content-Type': 'text/plain; charset=UTF-8',
		    'Cache-Control': 'max-age=10',
		    // 'Pragma': 'no-cache'
		});
		response.end("Can't read the file: " + err);
	    } else
		sendErr(response, 500, "Can't read the file: " + err);
	}
    });
}

function parseCmdLine() {
    // configuration of acception options; the `flag' property of
    // options indicates that the option is only a flag that don't
    // expect argument, if false then the next argument in the line
    // will be the option value.
    config = {
	'-d': { flag:false, id:'dir' },
	'--dir': { flag:false, id:'dir' },
    };

    // the returned object of this function
    var ret = {
	options: {},
	args: [],
	err: undefined,
	scriptDir: path.dirname(process.argv[1])
    };

    // the start state
    function start(arg) {
	if (arg[0] === '-') {
	    if (! config.hasOwnProperty(arg)) {
		// a string result indicates an error
		return 'Option "' + arg + '" does not exists';
	    }
	    var argConf = config[arg];
	    if (argConf.flag)
		ret.options[argConf.id] = true;
	    else {
		return newArgValState(argConf);
	    }
	} else
	    ret.args.push(arg);
    }

    // a function that build the state of the parsing of a non flag
    // option
    function newArgValState(argConf) {
	return function (arg) {
	    ret.options[argConf.id] = arg;
	    return start;
	}
    }

    // initialize the initial state
    var state = start;

    // loop on arguments
    for (var ai = 2; ai < process.argv.length; ++ai) {
	var newState = state(process.argv[ai]);

	// update the state if a new one is provided
	if (typeof newState === 'string') {
	    // string state is an error message; we return an object
	    // with only an err object
	    return {
		err: newState
	    };
	} else if (newState !== undefined)
	    state = newState;
    }

    return ret;
}

// Function take from html5 specification
function extractFilename(path) {
    if (path.substr(0, 12) == "C:\\fakepath\\")
	return path.substr(12); // modern browser
    var x;
    x = path.lastIndexOf('/');
    if (x >= 0) // Unix-based path
	return path.substr(x+1);
    x = path.lastIndexOf('\\');
    if (x >= 0) // Windows-based path
	return path.substr(x+1);
    return path; // just the filename
}

function handleUpload(request, response) {
    // parse content type to obtain boundary delimiter
    var ct = request.headers['content-type'];
    if (ct === undefined) {
	sendErr(response, 400, 'No Content-Type header in request.');
	return;
    }
    ct = mime.parseContentType(ct);
    if (ct.type !== 'multipart/form-data') {
	sendErr(response, 400, 'Expect multipart/form-data type of content, got ' + ct.type + '.');
	return;
    }
    var boundary = ct.parameters.boundary;
    if (boundary === undefined) {
	sendErr(response, 400, 'No boundary parameter along multipart/form-data type of content.');
	return;
    }

    // here we start asynchronous actions to save data to
    // files; for each pending action we increment the
    // pendingOperations variable and decrement it when
    // this action is called. When this counter reaches 0,
    // there is no more pending actions s the response can
    // finally be returned; this is what sendEnd() do.
    //
    // Note that pendingOperations count is initially 1
    // since we are also waiting for "end" event.
    var pendingOperations = 1;

    // instantiate MIME parser
    var uploadErr;
    function setUploadErr(err) {
	if (uploadErr === undefined)
	    uploadErr = "" + err;
    }
    function sendEnd() {
	if (uploadErr !== undefined) {
	    sendErr(response, 500, uploadErr);
	} else {
	    var url = "/";
	    var html = '<!doctype html><html><head><meta charset="utf-8"><title>Redirecting: File successfully uploaded</title></head><body><h1>Redirecting: File successfully uploaded</h1><p>File successfully uploaded, you should have been redirected now, <a href="' + url + '">click here</a> if this is not the case.</p></body></html>';
	    response.setHeader("Location", url);
	    sendHtml(response, 303, html);
	}
    }
    var mimeParser = new mime.MimeParser(boundary, function (headers, buffer) {
	// parse part header starting with content-disposition
	if (headers['content-disposition'] === undefined) {
	    setUploadErr("No 'content-disposition' header in mime part");
	    return;
	}

	// check that content-disposition is form-data
	if (headers['content-disposition'].type !== 'form-data') {
	    setUploadErr("Content disposition of part is not 'form-data' but '" + 
			 headers['content-disposition'].type + "'");
	    return;
	}

	// check that we have the name attribute
	var inputName = headers['content-disposition'].parameters.name;
	if (inputName === undefined) {
	    setUploadErr("No 'name' parameter in Content disposition");
	    return;
	}

	// test that we have a filename attribute
	var filename = headers['content-disposition'].parameters.filename;
	if (filename === undefined) {
	    setUploadErr("No 'filename' parameter in Content disposition");
	    return;
	}

	// we bypass empty files
	if (filename === "")
	    return;

	// remove path from file name in case filename contains / or \
	filename = extractFilename(filename);

	// check if there is an explicit content-type in header
	var partCT = headers['content-type'];
	if (partCT === undefined)
	    partCT = new mime.ContentType("text/plain");

	// check that we are not using any transforming encoding
	var cte = headers['content-transfer-encoding'];
	if (cte !== undefined) {
	    var type = cte.type.toLowerCase();
	    // test if type is not identity
	    if (type !== "7bit" || type !== "8bit" || type !== "binary") {
		setUploadErr("Not supported 'content-transfer-encoding': " + cte.type);
		return;
	    }
	}

	// find a free file name to write content to
	var extension = path.extname(filename);
	var base = path.basename(filename, extension);
	var testPath = path.join(rootDir, base + extension);
	var repeat = 0;
	function tryFunction(exists) {
	    --pendingOperations; // exists is no more
	    // pending; we don't
	    // sendEnd before exit of
	    // function
	    try {
		if (exists) {
		    console.log("File exists: " + testPath);
		    ++repeat;
		    if (repeat >= 500) {
			setUploadErr("Try " + repeat + " times to find a filename and failed");
			return;
		    }
		    testPath = path.join(rootDir, base + '.' + repeat + extension);
		    // recursion
		    fs.exists(testPath, tryFunction)
		    ++pendingOperations;
		} else {
		    console.log("Writing: " + testPath);
		    fs.writeFile(testPath, buffer, function (err) {
			--pendingOperations;
			try {
			    if (err !== null)
				setUploadErr("Failed to save file to disk: " + err);
			    else
				console.log("Uploaded: " + testPath);
			} finally {
			    if (pendingOperations === 0)
				sendEnd();
			}
		    });
		    ++pendingOperations;
		}
	    } finally {
		if (pendingOperations === 0)
		    sendEnd();
	    }
	}
	fs.exists(testPath, tryFunction);
	++pendingOperations; // the exists is pending
    });
    
    request.on('data', function(chunck) {
	mimeParser.update(chunck);
    });
    request.on('end', function() {
	--pendingOperations;
	if (pendingOperations === 0)
	    sendEnd();
    });
}

function handleRoot(request, response) {
    fs.readdir(rootDir, function (err, files) {
	if (err != null) {
	    sendErr(response, 500, 'Failed to list root directory: ' + err + '.');
	    return;
	}

	// compute the stat of files
	filesData = [];
	for (var fi = 0; fi < files.length; ++fi) {
	    filesData.push({ name: files[fi], stat: null });
	}

	var pendingComputations = filesData.length;
	if (pendingComputations >= 1) {
	    for (var fd = 0; fd < filesData.length; ++fd) {
		(function (fileData) {
		    var filePath = path.resolve(rootDir, fileData.name);
		    fs.stat(filePath,
			    function (err, stat) {
				try {
				    if (err != null) {
					console.log("failed to stat:" + filePath);
					return;
				    }
				    fileData.stat = stat;
				} finally {
				    --pendingComputations;
				    if (pendingComputations == 0) {
					sendPage();
				    }
				}
			    });
		})(filesData[fd]);
	    }
	} else
	    // if there is no file to list, don't expect the last
	    // completion to call sendPage
	    sendPage();
	
	function sendPage() {
	    var respTxt = "";
	    if (files.length >= 1) {
		for (var fd = 0; fd < files.length; ++fd) {
		    var fileData = filesData[fd];
		    respTxt += '<div class="file"><a href="content/' + encodeURIComponent(fileData.name) + '">' + fileData.name + '</a></div>';
		    respTxt += '<div class="fileinfo"><span class="date">' + 
			(fileData.stat != null ? 
			 fileData.stat.mtime.toGMTString() :
			 '---');
		    respTxt += '</span><span class="sep"></span>' + 
			(fileData.stat != null ? 
			 filesize2string(fileData.stat.size).replace(' ','&nbsp;') :
			 '---&nbsp;octets') + '</div>';
		}
	    } else {
      		respTxt = '<div class="help"><span id="arrow"></span>No files have been uploaded yet, click on the "Upload" button above to start uploading files.</div>'
	    }

	    sendTemplateFile(response, 200, 'text/html; charset=UTF-8', 
			     '3ds-server-index.html', 
			     { files: respTxt });
	}
    });
}

function handleContent(request, response, filename) {
    // decode filename as a component or URI now so that if '/' is
    // encoded in URI we will spot it in next test
    filename = decodeURIComponent(filename);

    // fail if filename contains / or \ characters
    if (/\/|\\/.test(filename)) {
	sendErr(response, 404, "Invalid file name: " + filename + " it contains either / or \\.");
	return;
    }
    
    // find the file
    var filepath = path.resolve(rootDir, filename);

    // findout its mimetype
    fs.readFile(filepath, function (err, data) {
	if (err !== null) {
	    sendErr(response, 404, "File name: " + filename + " does not exists.");
	    return;
	}

	var mimetype = 'application/octet-stream';
	if (data.length >= 2 && 
	    data[0] == 0xff && 
	    data[1] == 0xd8)
	    mimetype = 'image/jpeg'

	else if (data.length >= 8 && 
		 data[0] == 0x89 &&
		 data[1] == 0x50 &&
		 data[2] == 0x4e &&
		 data[3] == 0x47 &&
		 data[4] == 0x0d &&
		 data[5] == 0x0a &&
		 data[6] == 0x1a &&
		 data[7] == 0x0a)
	    mimetype = 'image/png';
	sendFile(response, 200, mimetype, filepath);
    });
}

var dispatcher = {
    '/': handleRoot,
    '/upload.js': { ct: 'text/javascript', filename:'3ds-server-upload.js' },
    '/favicon.ico': { ct: 'image/vnd.microsoft.icon', filename: 'favicon.ico' },
    '/img/bgnd.png': { ct: 'image/png', filename: 'bgnd_pattern (indexed).png' },
    '/img/title-shadow.png': { ct: 'image/png', filename: 'title-shadow.png' },
    '/img/sprites.png': { ct: 'image/png', filename: 'sprites.png'}, 
    '/main.css': { ct: 'text/css', filename: '3ds-main.css' },
    '/err.css': { ct: 'text/css', filename: '3ds-err.css' },
};

var rootDir;	// the directory where uploads should be stored
var cmdLine;	// the result of the parsing of the command line
var scriptDir;	// the directory that contains the javascript files

function main() {
    // parse the command line
    cmdLine = parseCmdLine();
    if (cmdLine.err !== undefined) {
	console.log("Error parsing the command line: " + cmdLine.err);
	return 1;
    }
    if (cmdLine.options.dir === undefined) {
	console.log('The -d/--dir option is mandatory');
	return 1;
    }
    
    // set the root directory
    rootDir = cmdLine.options.dir;
    scriptDir = cmdLine.scriptDir
    console.log('Serving in directory: ' + rootDir);

    // run the server
    http.createServer(function(request, response) {
	var u = url.parse(request.url);
	var partiallyDecodedPath = decodeURI(u.pathname);
	if (request.method === 'GET') {
	    if (dispatcher.hasOwnProperty(partiallyDecodedPath)) {
		var dispatch = dispatcher[partiallyDecodedPath];
		if (typeof(dispatch) === 'function')
		    dispatch(request, response);
		else
		    sendFile(response, 200, dispatch.ct, dispatch.filename);
	    } else if (/^\/content\//.test(partiallyDecodedPath)) {
		handleContent(request, response, partiallyDecodedPath.slice(9));
	    } else
		sendErr(response, 404, 'Page ' + request.url + ' not found on this server.');
	} else if (request.method === 'POST') {
	    if (partiallyDecodedPath === '/upload') {
		handleUpload(request, response);
	    } else {
		sendErr(response, 404, 'Page ' + request.url + ' not found on this server.');
	    }
	} else {
	    sendErr(response, 501, 'Method ' + request.method + ' is not implemented on this server.');
	}
    }).listen(8080);
};

main();
